computer-scienceangular-roadmapbackend-roadmapblockchain-roadmapdba-roadmapdeveloper-roadmapdevops-roadmapfrontend-roadmapgo-roadmaphactoberfestjava-roadmapjavascript-roadmapnodejs-roadmappython-roadmapqa-roadmapreact-roadmaproadmapstudy-planvue-roadmapweb3-roadmap
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
181 lines
5.2 KiB
181 lines
5.2 KiB
import { |
|
useEffect, |
|
useMemo, |
|
useRef, |
|
useState, |
|
type InputHTMLAttributes, |
|
} from 'react'; |
|
import { cn } from '../../lib/classname'; |
|
import { useOutsideClick } from '../../hooks/use-outside-click'; |
|
import { useDebounceValue } from '../../hooks/use-debounce'; |
|
import { httpGet } from '../../lib/http'; |
|
import { useToast } from '../../hooks/use-toast'; |
|
|
|
type GetTopAIRoadmapTermResponse = { |
|
_id: string; |
|
term: string; |
|
title: string; |
|
}[]; |
|
|
|
type AITermSuggestionInputProps = { |
|
value: string; |
|
onValueChange: (value: string) => void; |
|
onSelect?: (roadmapId: string) => void; |
|
inputClassName?: string; |
|
wrapperClassName?: string; |
|
placeholder?: string; |
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onSelect' | 'onChange'>; |
|
|
|
export function AITermSuggestionInput(props: AITermSuggestionInputProps) { |
|
const { |
|
value: defaultValue, |
|
onValueChange, |
|
onSelect, |
|
inputClassName, |
|
wrapperClassName, |
|
placeholder, |
|
...inputProps |
|
} = props; |
|
|
|
const termCache = useMemo( |
|
() => new Map<string, GetTopAIRoadmapTermResponse>(), |
|
[], |
|
); |
|
|
|
const toast = useToast(); |
|
const searchInputRef = useRef<HTMLInputElement>(null); |
|
const dropdownRef = useRef<HTMLDivElement>(null); |
|
|
|
const [isActive, setIsActive] = useState(false); |
|
const [searchResults, setSearchResults] = |
|
useState<GetTopAIRoadmapTermResponse>([]); |
|
const [searchedText, setSearchedText] = useState(defaultValue); |
|
const [activeCounter, setActiveCounter] = useState(0); |
|
const debouncedSearchValue = useDebounceValue(searchedText, 500); |
|
|
|
const loadTopAIRoadmapTerm = async () => { |
|
if (debouncedSearchValue.length === 0) { |
|
return []; |
|
} |
|
|
|
if (termCache.has(debouncedSearchValue)) { |
|
const cachedData = termCache.get(debouncedSearchValue); |
|
return cachedData || []; |
|
} |
|
|
|
const { response, error } = await httpGet<GetTopAIRoadmapTermResponse>( |
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-top-ai-roadmap-term`, |
|
{ |
|
term: debouncedSearchValue, |
|
}, |
|
); |
|
|
|
if (error || !response) { |
|
toast.error(error?.message || 'Something went wrong'); |
|
setSearchResults([]); |
|
return []; |
|
} |
|
|
|
termCache.set(debouncedSearchValue, response); |
|
return response; |
|
}; |
|
|
|
useEffect(() => { |
|
if (debouncedSearchValue.length === 0) { |
|
setSearchResults([]); |
|
return; |
|
} |
|
|
|
setIsActive(true); |
|
loadTopAIRoadmapTerm().then((results) => { |
|
setSearchResults(results?.slice(0, 5) || []); |
|
setActiveCounter(0); |
|
}); |
|
}, [debouncedSearchValue]); |
|
|
|
useOutsideClick(dropdownRef, () => { |
|
setIsActive(false); |
|
}); |
|
|
|
return ( |
|
<div className={cn('relative', wrapperClassName)}> |
|
<input |
|
{...inputProps} |
|
ref={searchInputRef} |
|
type="text" |
|
value={searchedText} |
|
className={cn( |
|
'w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none', |
|
inputClassName, |
|
)} |
|
placeholder={placeholder} |
|
autoComplete="off" |
|
onInput={(e) => { |
|
const value = (e.target as HTMLInputElement).value.trim(); |
|
setSearchedText(value); |
|
onValueChange(value); |
|
}} |
|
onFocus={() => { |
|
setIsActive(true); |
|
}} |
|
onKeyDown={(e) => { |
|
if (e.key === 'ArrowDown') { |
|
const canGoNext = activeCounter < searchResults.length - 1; |
|
setActiveCounter(canGoNext ? activeCounter + 1 : 0); |
|
} else if (e.key === 'ArrowUp') { |
|
const canGoPrev = activeCounter > 0; |
|
setActiveCounter( |
|
canGoPrev ? activeCounter - 1 : searchResults.length - 1, |
|
); |
|
} else if (e.key === 'Tab') { |
|
if (isActive) { |
|
e.preventDefault(); |
|
} |
|
} else if (e.key === 'Escape') { |
|
setSearchedText(''); |
|
setIsActive(false); |
|
} else if (e.key === 'Enter') { |
|
e.preventDefault(); |
|
const activeData = searchResults[activeCounter]; |
|
if (activeData) { |
|
onValueChange(activeData.term); |
|
onSelect?.(activeData._id); |
|
setIsActive(false); |
|
} |
|
} |
|
}} |
|
/> |
|
|
|
{isActive && searchResults.length > 0 && ( |
|
<div |
|
className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white px-2 py-2 shadow" |
|
ref={dropdownRef} |
|
> |
|
<div className="flex flex-col"> |
|
{searchResults.map((result, counter) => { |
|
return ( |
|
<button |
|
key={result?._id} |
|
type="button" |
|
className={cn( |
|
'flex w-full items-center rounded p-2 text-sm', |
|
counter === activeCounter ? 'bg-gray-100' : '', |
|
)} |
|
onMouseOver={() => setActiveCounter(counter)} |
|
onClick={() => { |
|
onValueChange(result?.term); |
|
onSelect?.(result._id); |
|
setSearchedText(''); |
|
setIsActive(false); |
|
}} |
|
> |
|
{result?.title || result?.term} |
|
</button> |
|
); |
|
})} |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
}
|
|
|