import { debounce, isEmpty } from 'lodash'; import * as React from 'react'; import { useCallback, useRef, useState } from 'react'; import Icon from './Icon'; import ListItem from './ListItem'; // 1. Group related constants const CONFIG = { HOST: '', LOG: false, } as const; const STYLES = { LIST_ELEM_CLASSNAME: [ 'w-full p-2 pl-8 text-xl font-light border-slate-300 border-2', 'group-focus-within:border-slate-400', ], SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`, SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`, INPUT_CLASSNAME: `text-slate-500 focus:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`, } as const; // 2. Simplify logging const log = { info: (...args: any[]) => CONFIG.LOG && console.log(...args), error: (...args: any[]) => CONFIG.LOG && console.error(...args), }; interface PropTypes { isServerRendered?: boolean; } interface User { id: number; name: string; thumb?: string; show_path: string; num_posts?: number; distance: number; matched_name: string; domain_icon: string; } interface ServerResponse { users: User[]; } export default function UserSearchBar({ isServerRendered }: PropTypes) { isServerRendered = !!isServerRendered; const [pendingRequest, setPendingRequest] = useState( null, ); const [state, setState] = useState({ userName: '', userList: [] as ServerResponse['users'], selectedIdx: null as number | null, errorMessage: null as string | null, typingSettled: true, isFocused: isServerRendered ? false : true, }); const inputRef = useRef(null); const clearResults = useCallback(() => { setState((s) => ({ ...s, userList: [], errorMessage: null, selectedIdx: null, })); }, []); const cancelPendingRequest = useCallback(async () => { if (pendingRequest) { setPendingRequest(null); pendingRequest.abort(); } }, [pendingRequest, setPendingRequest]); const sendSearchRequest = useCallback( (userName) => { cancelPendingRequest(); const controller = new AbortController(); setPendingRequest(controller); async function sendRequest() { try { let req = await fetch( `${CONFIG.HOST}/users/search_by_name.json?name=${userName}`, { signal: controller.signal, }, ); setPendingRequest(null); setState((s) => ({ ...s, errorMessage: null, })); if (req.status != 200) { const error_json = await req.json(); setState((s) => ({ ...s, errorMessage: `error loading users: ${error_json.error || JSON.stringify(error_json)}`, })); } else { let gotUserList = await req.json(); setState((s) => ({ ...s, userList: gotUserList.users, })); } } catch (err) { if (!err.message.includes('aborted')) { setState((s) => ({ ...s, errorMessage: `error loading users: ` + err.message, })); } } } sendRequest(); return () => controller.abort(); }, [cancelPendingRequest, setPendingRequest], ); const searchForUser = useCallback( (userName: string) => { setState((s) => ({ ...s, userName })); if (isEmpty(userName)) { clearResults(); } else { sendSearchRequest(userName); } }, [clearResults, sendSearchRequest], ); const searchForUserDebounced = useCallback( debounce(async (userName) => { setState((s) => ({ ...s, typingSettled: true })); searchForUser(userName); }, 250), [searchForUser], ); function invokeIdx(idx) { const user = state.userList[idx]; if (user) { setState((s) => ({ ...s, userName: user.name })); inputRef.current.value = user.name; window.location.href = user.show_path; } } function invokeSelected() { if (state.selectedIdx != null) { invokeIdx(state.selectedIdx); } } const visibility = { error: state.isFocused && !isEmpty(state.errorMessage), info: state.isFocused && !isEmpty(state.userName) && !pendingRequest && state.typingSettled && state.userList.length === 0, items: !isEmpty(state.userName) && state.userList.length > 0, }; const anyShown = Object.values(visibility).some(Boolean); function UserSearchBarItems() { return (
{visibility.error ? ( ) : null} {visibility.info ? ( ) : null} {visibility.items ? state.userList.map( ({ name, thumb, show_path, num_posts, domain_icon }, idx) => ( ), ) : null}
); } const keyHandlers = { Tab: (shiftKey: boolean) => shiftKey ? selectPrevListElem() : selectNextListElem(), ArrowDown: () => selectNextListElem(), ArrowUp: () => selectPrevListElem(), Enter: () => invokeSelected(), }; function onSearchInputKeyDown(event: React.KeyboardEvent) { const handler = keyHandlers[event.code]; if (handler) { event.preventDefault(); handler(event.shiftKey); } } function selectNextListElem() { setNewIdxTruncated(state.selectedIdx == null ? 0 : state.selectedIdx + 1); } function selectPrevListElem() { setNewIdxTruncated(state.selectedIdx == null ? -1 : state.selectedIdx - 1); } function setNewIdxTruncated(newIdx) { if (state.userList.length == 0) { newIdx = null; } else { if (newIdx >= state.userList.length) { newIdx = 0; } else if (newIdx < 0) { newIdx = state.userList.length - 1; } } setState((s) => ({ ...s, selectedIdx: newIdx })); } return (
{anyShown && (
)}
); }