import React, { createRef, ReactElement, useEffect, useState } from 'react'; import { debounce } from 'debounce'; import styled from 'styled-components/macro'; import tw from 'twin.macro'; import Input from '@/components/elements/Input'; import Label from '@/components/elements/Label'; import InputSpinner from '@/components/elements/InputSpinner'; const Dropdown = styled.div<{ expanded: boolean }>` ${tw`absolute mt-1 w-full rounded-md bg-neutral-900 shadow-lg z-10`}; ${props => !props.expanded && tw`hidden`}; `; interface SearchableSelectProps { id: string; name: string; nullable: boolean; selected: T | null; items: T[]; setItems: (items: T[]) => void; onSearch: (query: string) => Promise; onSelect: (item: T) => void; getSelectedText: (item: T | null) => string; children: React.ReactNode; } function SearchableSelect ({ id, name, selected, items, setItems, onSearch, onSelect, getSelectedText, children }: SearchableSelectProps) { const [ loading, setLoading ] = useState(false); const [ expanded, setExpanded ] = useState(false); const [ inputText, setInputText ] = useState(''); const searchInput = createRef(); const itemsList = createRef(); const onFocus = () => { setInputText(''); setItems([]); setExpanded(true); }; const onBlur = () => { setInputText(getSelectedText(selected) || ''); setExpanded(false); }; const search = debounce((query: string) => { if (!expanded) { return; } if (query === '' || query.length < 2) { setItems([]); return; } setLoading(true); onSearch(query).then(() => setLoading(false)); }, 1000); useEffect(() => { setInputText(getSelectedText(selected) || ''); setExpanded(false); }, [ selected ]); useEffect(() => { const keydownHandler = (e: KeyboardEvent) => { if (e.key !== 'Tab' && e.key !== 'Escape') { return; } onBlur(); }; 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('keydown', keydownHandler); window.addEventListener('click', clickHandler); window.addEventListener('contextmenu', contextmenuHandler); return () => { window.removeEventListener('keydown', keydownHandler); window.removeEventListener('click', clickHandler); window.removeEventListener('contextmenu', contextmenuHandler); }; }, [ expanded ]); const onClick = (item: T) => (e: React.MouseEvent) => { e.preventDefault(); onSelect(item); }; // This shit is really stupid but works, so is it really stupid? const c = React.Children.map(children, child => React.cloneElement(child as ReactElement, { onClick: onClick.bind(child), })); // @ts-ignore const selectedId = selected?.id; return (
{ setInputText(e.currentTarget.value); search(e.currentTarget.value); }} />
{ items.length < 1 ? inputText.length < 2 ?

Please type 2 or more characters.

:

No results found.

:
    {c}
}
); } interface OptionProps { selectId: string; id: string | number; item: T; active: boolean; onClick?: (item: T) => (e: React.MouseEvent) => void; children: React.ReactNode; } export function Option ({ selectId, id, item, active, onClick, children }: OptionProps) { // 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}
  • ); } export default SearchableSelect;