import { ChevronDownIcon, ChevronUpIcon, XIcon } from 'lucide-react'; import clsx from 'clsx'; import React, { ReactNode, useEffect, useRef, useState } from 'react'; import { AlignType, Popup, PopupController } from "./popup/index"; const INPUT_UNSTYLED = "block m-0 p-0 border-0 focus:outline-none focus:ring-0"; const INPUT_NO_PADDING = "block sm:text-sm sm:leading-6 bg-muted rounded-md border-0 shadow-sm ring-1 ring-inset ring-muted placeholder:text-muted focus:ring-2 focus:ring-inset focus:ring-primary" const INPUT = INPUT_NO_PADDING + " py-1.5"; const COMBOBOX_POPUP = "combobox-popup"; function genComboboxPopupId() { return `combobox-popup-${Math.floor(Math.random() * 1000000)}`; } export abstract class OptionAdapter { abstract valueOf(item: T): string; abstract idOf(item: T): string; filter(items: T[], text: string) { const lcText = text.toLowerCase(); return items.filter((item: T) => this.valueOf(item).toLowerCase().includes(lcText)); } renderOption(item: T): ReactNode { return this.valueOf(item); } findById(items: T[], id: string) { return items.find(item => this.idOf(item) === id); } // override to support creating new items createItem(_value: string): T | null { return null; // default is no new item } } export class StringOptionAdapter extends OptionAdapter { valueOf(item: string): string { return String(item); } idOf(item: string): string { return String(item); } static instance = new StringOptionAdapter(); } export class StringOptionAdapterWithCreate extends StringOptionAdapter { createItem(value: string): string { return value; } static instance = new StringOptionAdapterWithCreate(); } export interface ComboBoxLayoutProps { buttonRight?: number; buttonWidth?: number; maxMenuHeight?: number; menuClass?: string; inputClass?: string; optionClass?: string; Input?: React.ComponentType>; Menu?: React.ComponentType>; Toggle?: React.ComponentType> | null; } export type ComboBoxLayout = Required> export function getDefaultComboBoxLayout(fullWidth?: boolean, unstyledInput?: boolean): ComboBoxLayout { return { buttonRight: 4, buttonWidth: 24, maxMenuHeight: 240, menuClass: "w-72 mt-1 border-popover bg-popover text-popover-foreground shadow-md overflow-auto p-0 z-10", inputClass: clsx(unstyledInput ? INPUT_UNSTYLED : INPUT, fullWidth ? "w-full" : "!w-auto"), optionClass: "py-2 px-3 shadow-sm flex flex-col [&.option-selected]:font-semibold [&.option-highlighted]:bg-blue-300", Input: ComboInput, Menu: ComboMenu, Toggle: ComboToggle, } } export interface ComboBoxApi { open: () => void; close: () => void; toggle: () => void; inputValue: string; selectedItem: T | null; setInputValue: (value: string) => void; focus: () => void; } export interface ComboBoxProps { items: T[]; adapter: OptionAdapter; // if true then the default layout will use an unstyled input unstyledInput?: boolean; // if true then the default layout will use w-full on the input fullWidth?: boolean; layout?: ComboBoxLayoutProps; placeholder?: string; api?: React.MutableRefObject | null>; //TODO value?: T | null; //TODO onselect too? onSelect?: (value: T | null) => void; // menu zIndex zIndex?: number; focusOnMount?: boolean menuGap?: number; menuAlign?: AlignType; // show X button to clear the current selection clearable?: boolean; // shown inside the dropdown when filter produces zero results noMatchMessage?: ReactNode; // open the menu when the input is focused openOnFocus?: boolean; } export function ComboBox({ menuAlign = "fill", menuGap, focusOnMount, onSelect, value, zIndex, unstyledInput, fullWidth, api, layout: layoutOpts, adapter, items, placeholder, clearable, noMatchMessage, openOnFocus }: ComboBoxProps) { const [popupId] = useState(genComboboxPopupId()); const popupCtrl = useRef(undefined); const inputRef = useRef(null); const layout: ComboBoxLayout = layoutOpts ? Object.assign(getDefaultComboBoxLayout(fullWidth, unstyledInput), layoutOpts) : getDefaultComboBoxLayout(fullWidth, unstyledInput); const inputBoxRef = React.useRef(null); const ctrl = useComboboxCtrl({ adapter, items, value, popupId }); useEffect(() => { if (inputRef.current) { focusOnMount && inputRef.current.focus(); } }, [inputRef.current]); // the onSelect callback may change so we need to refresh it. useEffect(() => { ctrl.onSelect = onSelect ctrl.popupCtrl = popupCtrl.current; }, [onSelect, popupCtrl.current]); useEffect(() => { if (api && ctrl && inputRef.current) { api.current = { open: () => ctrl.openMenu(), close: () => ctrl.closeMenu(), toggle: () => ctrl.toggleMenu(), setInputValue: (value: string) => ctrl.inputText = value, inputValue: ctrl.inputText || '', selectedItem: ctrl.selectedItem, focus: () => inputRef.current?.focus() } return () => { api.current = null; } } }, [api, ctrl, inputRef.current]); const showMenu = ctrl.isMenuOpen && (ctrl.filteredItems.length > 0 || !!noMatchMessage); return ( <> ctrl.closeMenu()} isOpen={showMenu} anchor={inputBoxRef} zIndex={zIndex} constraints={{ position: "bottom", align: menuAlign, gap: menuGap != null ? menuGap : 4 }}> ); } export interface ComboInputProps { layout: ComboBoxLayout; ctrl: ComboboxController, placeholder?: string boxRef?: React.RefObject; inputRef?: React.RefObject; clearable?: boolean; openOnFocus?: boolean; } function ComboInput({ inputRef, placeholder, boxRef, ctrl, layout, clearable, openOnFocus }: ComboInputProps) { const Toggle = layout.Toggle; const showClear = clearable && ctrl.selectedItem != null; const buttonCount = (Toggle ? 1 : 0) + (showClear ? 1 : 0); const style = buttonCount > 0 ? { paddingRight: `${layout.buttonWidth * buttonCount + layout.buttonRight}px` } : undefined; return (
ctrl.openMenu() : undefined} style={style} className={layout.inputClass} />
{showClear && ( )} {Toggle && ( )}
) } export interface ComboToggleProps { ctrl: ComboboxController; layout: ComboBoxLayout; } function ComboToggle({ ctrl }: ComboToggleProps) { return ctrl.isMenuOpen ? : ; } export interface ComboMenuProps { items: T[]; layout: ComboBoxLayout; ctrl: ComboboxController; adapter: OptionAdapter; fillWidth: boolean; noMatchMessage?: ReactNode; } function ComboMenu({ fillWidth, items, layout, ctrl, adapter, noMatchMessage }: ComboMenuProps) { const { highlightedIndex, selectedItem } = ctrl; if (items.length === 0) { return noMatchMessage ?
{noMatchMessage}
: null; } return (
    {items.map((item, index) => (
  • {adapter.renderOption(item)}
  • )) }
) } export interface ComboboxControllerProps { adapter: OptionAdapter, items: ItemT[], value?: ItemT | string | null, popupId: string; } export function useComboboxCtrl(props: ComboboxControllerProps): ComboboxController { const [ctrl, setCtrl] = useState>(new ComboboxController(props)); useEffect(() => { ctrl?.withState(setCtrl); }, []); return ctrl; } class ComboboxController { private popupId: string; public items: ItemT[]; private adapter: OptionAdapter; onSelect?: (item: ItemT | null) => void; private setState?: (ctrl: ComboboxController) => void; private _selectedItem: ItemT | null = null; private _filteredItems: ItemT[]; private _inputText: string = ""; private _highlightedIndex: number | null = null; private _isMenuOpen: boolean = false; popupCtrl?: PopupController; constructor({ adapter, items, value, popupId }: ComboboxControllerProps) { this.adapter = adapter; this.items = items; this.popupId = popupId; if (typeof value === "string") { this._inputText = value; } else if (value) { this._selectedItem = adapter.findById(items, adapter.idOf(value)) || null; if (this._selectedItem) { this._inputText = adapter.valueOf(value); } } if (this._inputText) { this._filteredItems = this.adapter.filter(this.items, this._inputText); } else { this._filteredItems = this.items; } } withState(setState: (ctrl: ComboboxController) => void) { this.setState = setState; return this; } private clone() { const clone = new ComboboxController({ adapter: this.adapter, items: this.items, popupId: this.popupId }); clone.setState = this.setState; clone.onSelect = this.onSelect; clone._inputText = this._inputText; clone._highlightedIndex = this._highlightedIndex; clone._selectedItem = this._selectedItem; clone._isMenuOpen = this._isMenuOpen; clone._filteredItems = this._filteredItems; clone.popupCtrl = this.popupCtrl; return clone; } private updateState() { this.setState?.(this.clone()) } get filteredItems() { if (this._inputText) { return this.adapter.filter(this.items, this._inputText); } else { return this.items; } } get selectedItem() { return this._selectedItem; } set selectedItem(item: ItemT | null) { this._selectedItem = item; this._inputText = item ? this.adapter.valueOf(item) : ""; this._filteredItems = this._inputText ? this.adapter.filter(this.items, this._inputText) : this.items; this.updateState(); this.onSelect?.(item); } get isMenuOpen() { return this._isMenuOpen; } set inputText(inputText: string) { this._inputText = inputText; if (inputText) { this._filteredItems = this.adapter.filter(this.items, inputText); } else { this._filteredItems = this.items; } this._highlightedIndex = null; this._selectedItem = null; this.updateState(); // TODO -- experimental - it works but it's not perfect // we need to update if the popup is on top and the filtered items changed if (this.isMenuOpen && this.popupCtrl) { const popupCtrl = this.popupCtrl; const popupPosition = popupCtrl.context?.position?.position; if (popupPosition && popupPosition === "top") { window.setTimeout(() => { popupCtrl.update(); }, 100); } } } get inputText() { return this._inputText; } set highlightedIndex(index: number | null) { this._highlightedIndex = index; this.updateState(); } get highlightedIndex() { return this._highlightedIndex; } openMenu() { if (this._filteredItems.length > 0) { this._isMenuOpen = true; //this._highlightedIndex = 0; this.updateState(); } else { // TODO nothing to show. // display a create value option? } } closeMenu(_item?: ItemT | null) { if (this._isMenuOpen) { this._highlightedIndex = null; this._isMenuOpen = false; this.updateState(); } } toggleMenu() { if (this._isMenuOpen) { this.closeMenu(); } else { this.openMenu(); } } private highlightIndex(index: number, navigateToTop: boolean = false) { this.highlightedIndex = index; const popup = document.getElementById(this.popupId); if (popup) { popup.querySelector(`li[data-index="${index}"]`)?.scrollIntoView(navigateToTop); } } getMenuProps() { return {} } getToggleButtonProps() { return { onClick: () => { this._isMenuOpen = !this._isMenuOpen; this.updateState(); } } } getItemProps(item: ItemT, index: number): React.HTMLProps { return { "aria-selected": this._highlightedIndex === index, onClick: () => { this.selectedItem = item; this.closeMenu(); }, onMouseEnter: () => { if (this.highlightedIndex !== index) { this.highlightedIndex = index; } }, onMouseLeave: () => { if (this.highlightedIndex === index) { this.highlightedIndex = null; } } } } getInputProps() { const items = this._filteredItems; return { onClick: () => { this.openMenu(); }, onChange: (ev: React.ChangeEvent) => { const value = ev.target.value; this.inputText = value; this.openMenu(); }, value: this.inputText, onKeyDown: (ev: React.KeyboardEvent) => { const key = ev.key; if (key === "Enter") { if (this.highlightedIndex != null) { this.selectedItem = items[this.highlightedIndex || 0]; } else { // create new value? if (this.inputText) { const item = this.adapter.createItem(this.inputText); if (item) { this.items.push(item); this.selectedItem = item; } } } this.closeMenu(); } else if (key === "ArrowDown") { if (this.isMenuOpen) { this.highlightIndex(this.highlightedIndex === null ? 0 : incrModulo(this.highlightedIndex, items.length), false); } else { this.openMenu(); } } else if (key === "ArrowUp") { if (this.isMenuOpen) { this.highlightIndex(this.highlightedIndex === null ? 0 : decrModulo(this.highlightedIndex, items.length), true); } } } } } } function incrModulo(value: number, max: number) { return (value + 1) % max; } function decrModulo(value: number, max: number) { return (value - 1 + max) % max; } export function SimpleCombobox({ options, creatable, ...rest }: Omit, 'adapter' | 'items'> & { options: string[]; creatable?: boolean; }) { const adapter = creatable ? StringOptionAdapterWithCreate.instance : StringOptionAdapter.instance; return ; }