import { debounce } from 'debounce'; import React, { createRef, ReactElement, useEffect, useState } from 'react'; import tw, { styled } from 'twin.macro'; import Input from '@/components/elements/Input'; import InputSpinner from '@/components/elements/InputSpinner'; import Label from '@/components/elements/Label'; const Dropdown = styled.div<{ expanded: boolean }>` ${tw`absolute z-10 w-full mt-1 rounded-md shadow-lg bg-neutral-900`}; ${props => !props.expanded && tw`hidden`}; `; interface OptionProps { selectId: string; id: number; item: T; active: boolean; isHighlighted?: boolean; onClick?: (item: T) => (e: React.MouseEvent) => void; children: React.ReactNode; } interface IdObj { id: number; } export const Option = ({ selectId, id, item, active, isHighlighted, onClick, children, }: OptionProps) => { if (isHighlighted === undefined) { isHighlighted = false; } // This should never be true, but just in-case we set it to an empty function to make sure shit doesn't blow up. if (onClick === undefined) { // eslint-disable-next-line @typescript-eslint/no-empty-function onClick = () => () => {}; } if (active) { return (
  • {children}
  • ); } return (
  • {children}
  • ); }; interface SearchableSelectProps { id: string; name: string; label: string; placeholder?: string; nullable?: boolean; selected: T | null; setSelected: (item: T | null) => void; items: T[] | null; setItems: (items: T[] | null) => void; onSearch: (query: string) => Promise; onSelect: (item: T | null) => void; getSelectedText: (item: T | null) => string | undefined; children: React.ReactNode; className?: string; } export const SearchableSelect = ({ id, name, label, placeholder, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children, className, }: SearchableSelectProps) => { const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(false); const [inputText, setInputText] = useState(''); const [highlighted, setHighlighted] = useState(null); const searchInput = createRef(); const itemsList = createRef(); const onFocus = () => { setInputText(''); setItems(null); setExpanded(true); setHighlighted(null); }; const onBlur = () => { setInputText(getSelectedText(selected) || ''); setItems(null); setExpanded(false); setHighlighted(null); }; const search = debounce((query: string) => { if (!expanded) { return; } if (query === '' || query.length < 2) { setItems(null); setHighlighted(null); return; } setLoading(true); onSearch(query).then(() => setLoading(false)); }, 250); const handleInputKeydown = (e: React.KeyboardEvent) => { if (e.key === 'Tab' || e.key === 'Escape') { onBlur(); return; } if (!items) { return; } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { // Prevent up and down arrows from moving the cursor in the input. e.preventDefault(); if (highlighted === null) { setHighlighted(items[0].id); return; } const item = items.find(i => i.id === highlighted); if (!item) { return; } let index = items.indexOf(item); if (e.key === 'ArrowUp') { if (--index < 0) { return; } } else { if (++index >= items.length) { return; } } setHighlighted(items[index].id); return; } // Prevent the form from being submitted if the user accidentally hits enter // while focused on the select. if (e.key === 'Enter') { e.preventDefault(); const item = items.find(i => i.id === highlighted); if (!item) { return; } setSelected(item); onSelect(item); } }; useEffect(() => { const clickHandler = (e: MouseEvent) => { const input = searchInput.current; const menu = itemsList.current; if (e.button === 2 || !expanded || !input || !menu) { return; } if (e.target === input || input.contains(e.target as Node)) { return; } if (e.target === menu || menu.contains(e.target as Node)) { return; } if (e.target === input || input.contains(e.target as Node)) { return; } if (e.target === menu || menu.contains(e.target as Node)) { return; } onBlur(); }; const contextmenuHandler = () => { onBlur(); }; window.addEventListener('mousedown', clickHandler); window.addEventListener('contextmenu', contextmenuHandler); return () => { window.removeEventListener('mousedown', clickHandler); window.removeEventListener('contextmenu', contextmenuHandler); }; }, [expanded]); const onClick = (item: T) => () => { onSelect(item); setExpanded(false); setInputText(getSelectedText(selected) || ''); }; useEffect(() => { if (expanded) { return; } setInputText(getSelectedText(selected) || ''); }, [selected]); // This shit is really stupid but works, so is it really stupid? const c = React.Children.map(children, child => React.cloneElement(child as ReactElement, { isHighlighted: ((child as ReactElement).props as OptionProps).id === highlighted, onClick: onClick.bind(child), }), ); return (
    { setInputText(e.currentTarget.value); search(e.currentTarget.value); }} onKeyDown={handleInputKeydown} className={'ignoreReadOnly'} placeholder={placeholder} />
    {inputText !== '' && expanded && ( { e.preventDefault(); setInputText(''); }} > )}
    {items === null || items.length < 1 ? ( items === null || inputText.length < 2 ? (

    Please type 2 or more characters.

    ) : (

    No results found.

    ) ) : (
      {c}
    )}
    ); }; export default SearchableSelect;