diff --git a/resources/scripts/components/admin/nodes/DatabaseSelect.tsx b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx index c377b46d4..eb19231b6 100644 --- a/resources/scripts/components/admin/nodes/DatabaseSelect.tsx +++ b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx @@ -34,6 +34,7 @@ export default ({ selected }: { selected: Database | null }) => { name="Database" items={databases} selected={database} + setSelected={setDatabase} setItems={setDatabases} onSearch={onSearch} onSelect={onSelect} diff --git a/resources/scripts/components/admin/nodes/LocationSelect.tsx b/resources/scripts/components/admin/nodes/LocationSelect.tsx index db9fc646d..c0e0670b8 100644 --- a/resources/scripts/components/admin/nodes/LocationSelect.tsx +++ b/resources/scripts/components/admin/nodes/LocationSelect.tsx @@ -34,6 +34,7 @@ export default ({ selected }: { selected: Location | null }) => { name="Location" items={locations} selected={location} + setSelected={setLocation} setItems={setLocations} onSearch={onSearch} onSelect={onSelect} diff --git a/resources/scripts/components/elements/SearchableSelect.tsx b/resources/scripts/components/elements/SearchableSelect.tsx index 1206b2fbc..7c0fffb59 100644 --- a/resources/scripts/components/elements/SearchableSelect.tsx +++ b/resources/scripts/components/elements/SearchableSelect.tsx @@ -11,12 +11,69 @@ const Dropdown = styled.div<{ expanded: boolean }>` ${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; nullable: boolean; selected: T | null; + setSelected: (item: T | null) => void; items: T[] | null; setItems: (items: T[] | null) => void; @@ -29,12 +86,14 @@ interface SearchableSelectProps { children: React.ReactNode; } -function SearchableSelect ({ id, name, selected, items, setItems, onSearch, onSelect, getSelectedText, children }: SearchableSelectProps) { +export const SearchableSelect = ({ id, name, selected, setSelected, items, setItems, onSearch, onSelect, getSelectedText, children }: 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(); @@ -42,11 +101,14 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o setInputText(''); setItems(null); setExpanded(true); + setHighlighted(null); }; const onBlur = () => { setInputText(getSelectedText(selected) || ''); + setItems(null); setExpanded(false); + setHighlighted(null); }; const search = debounce((query: string) => { @@ -56,6 +118,7 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o if (query === '' || query.length < 2) { setItems(null); + setHighlighted(null); return; } @@ -63,20 +126,60 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o onSearch(query).then(() => setLoading(false)); }, 250); - useEffect(() => { - setInputText(getSelectedText(selected) || ''); - setExpanded(false); - }, [ selected ]); + const handleInputKeydown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' || e.key === 'Escape') { + onBlur(); + return; + } - useEffect(() => { - const keydownHandler = (e: KeyboardEvent) => { - if (e.key !== 'Tab' && e.key !== 'Escape') { + 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; } - onBlur(); - }; + const item = items.find((item) => item.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((item) => item.id === highlighted); + if (!item) { + return; + } + + setSelected(item); + } + }; + + useEffect(() => { const clickHandler = (e: MouseEvent) => { const input = searchInput.current; const menu = itemsList.current; @@ -108,39 +211,55 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o 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(); + 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), })); - // @ts-ignore - const selectedId = selected?.id; - return (
    - { - setInputText(e.currentTarget.value); - search(e.currentTarget.value); - }} + { + setInputText(e.currentTarget.value); + search(e.currentTarget.value); + }} + onKeyDown={handleInputKeydown} /> @@ -165,8 +284,8 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o tabIndex={-1} role={id + '-select'} aria-labelledby={id + '-select-label'} - aria-activedescendant={id + '-select-item-' + selectedId} - css={tw`py-1 overflow-auto text-base rounded-md max-h-56 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm`} + aria-activedescendant={id + '-select-item-' + selected?.id} + css={tw`py-2 overflow-auto text-base rounded-md max-h-56 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm`} > {c} @@ -175,53 +294,6 @@ function SearchableSelect ({ id, name, selected, items, setItems, onSearch, o
    ); -} - -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;