| import React, { forwardRef, useState, useCallback, useMemo, useEffect, useRef } from 'react'; |
| import debounce from 'lodash/debounce'; |
| import { useRecoilState } from 'recoil'; |
| import { Search, X } from 'lucide-react'; |
| import { QueryKeys } from 'librechat-data-provider'; |
| import { useQueryClient } from '@tanstack/react-query'; |
| import { useLocation, useNavigate } from 'react-router-dom'; |
| import { useLocalize, useNewConvo } from '~/hooks'; |
| import { cn } from '~/utils'; |
| import store from '~/store'; |
|
|
| type SearchBarProps = { |
| isSmallScreen?: boolean; |
| }; |
|
|
| const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivElement>) => { |
| const localize = useLocalize(); |
| const location = useLocation(); |
| const queryClient = useQueryClient(); |
| const navigate = useNavigate(); |
| const { isSmallScreen } = props; |
|
|
| const [text, setText] = useState(''); |
| const inputRef = useRef<HTMLInputElement>(null); |
| const [showClearIcon, setShowClearIcon] = useState(false); |
|
|
| const { newConversation: newConvo } = useNewConvo(); |
| const [search, setSearchState] = useRecoilState(store.search); |
|
|
| const clearSearch = useCallback( |
| (pathname?: string) => { |
| if (pathname?.includes('/search') || pathname === '/c/new') { |
| queryClient.removeQueries([QueryKeys.messages]); |
| newConvo({ disableFocus: true }); |
| navigate('/c/new'); |
| } |
| }, |
| [newConvo, navigate, queryClient], |
| ); |
|
|
| const clearText = useCallback( |
| (pathname?: string) => { |
| setShowClearIcon(false); |
| setText(''); |
| setSearchState((prev) => ({ |
| ...prev, |
| query: '', |
| debouncedQuery: '', |
| isTyping: false, |
| })); |
| clearSearch(pathname); |
| inputRef.current?.focus(); |
| }, |
| [setSearchState, clearSearch], |
| ); |
|
|
| const handleKeyUp = useCallback( |
| (e: React.KeyboardEvent<HTMLInputElement>) => { |
| const { value } = e.target as HTMLInputElement; |
| if (e.key === 'Backspace' && value === '') { |
| clearText(location.pathname); |
| } |
| }, |
| [clearText, location.pathname], |
| ); |
|
|
| const sendRequest = useCallback( |
| (value: string) => { |
| if (!value) { |
| return; |
| } |
| queryClient.invalidateQueries([QueryKeys.messages]); |
| }, |
| [queryClient], |
| ); |
|
|
| const debouncedSetDebouncedQuery = useMemo( |
| () => |
| debounce((value: string) => { |
| setSearchState((prev) => ({ ...prev, debouncedQuery: value, isTyping: false })); |
| sendRequest(value); |
| }, 500), |
| [setSearchState, sendRequest], |
| ); |
|
|
| const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const value = e.target.value; |
| setShowClearIcon(value.length > 0); |
| setText(value); |
| setSearchState((prev) => ({ |
| ...prev, |
| query: value, |
| isTyping: true, |
| })); |
| debouncedSetDebouncedQuery(value); |
| if (value.length > 0 && location.pathname !== '/search') { |
| navigate('/search', { replace: true }); |
| } |
| }; |
|
|
| |
| |
| useEffect(() => { |
| if (search.isTyping && !search.isSearching && search.debouncedQuery === search.query) { |
| setSearchState((prev) => ({ ...prev, isTyping: false })); |
| } |
| }, [search.isTyping, search.isSearching, search.debouncedQuery, search.query, setSearchState]); |
|
|
| return ( |
| <div |
| ref={ref} |
| className={cn( |
| 'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover', |
| isSmallScreen === true ? 'mb-2 h-14 rounded-xl' : '', |
| )} |
| > |
| <Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" /> |
| <input |
| type="text" |
| ref={inputRef} |
| className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary" |
| value={text} |
| onChange={onChange} |
| onKeyDown={(e) => { |
| e.code === 'Space' ? e.stopPropagation() : null; |
| }} |
| aria-label={localize('com_nav_search_placeholder')} |
| placeholder={localize('com_nav_search_placeholder')} |
| onKeyUp={handleKeyUp} |
| onFocus={() => setSearchState((prev) => ({ ...prev, isSearching: true }))} |
| onBlur={() => setSearchState((prev) => ({ ...prev, isSearching: false }))} |
| autoComplete="off" |
| dir="auto" |
| /> |
| <button |
| type="button" |
| aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`} |
| className={cn( |
| 'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200', |
| showClearIcon ? 'opacity-100' : 'opacity-0', |
| isSmallScreen === true ? 'right-[16px]' : '', |
| )} |
| onClick={() => clearText(location.pathname)} |
| tabIndex={showClearIcon ? 0 : -1} |
| disabled={!showClearIcon} |
| > |
| <X className="h-5 w-5 cursor-pointer" /> |
| </button> |
| </div> |
| ); |
| }); |
|
|
| export default SearchBar; |
|
|