mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 19:30:15 +01:00
* feat: wrap main content of page in <main> tag for screen reader landmarks (439) * feat: add italic on active convo when selected so that selection state does not rely on bg contrast ratio (562) * feat: add border ring around SearchBar so that it passes focus contrast minimums (577) * fix: hide decorative SVGs from screen readers (578) * fix: stop clipping of focus outlines in My Files modal (593) * feat: programmatically declare state of Temporary Chat toggle for screen readers (606) * feat: add sr-only components to warn screen readers that footer links open in new tab (611) * feat: add aria-labels to archived chat table buttons * feat: add screen reader heading for prompt edit page (776) * feat: increase contrast to threshold minimum for production tag in prompts advanced view (773) * feat: increase contrast to thehold minimums for production tag and version card border highlights (770) * fix: h2 now reads as 'control bar' to screen readers in edit prompt page (768) * feat: add selected state tracking for simple / advanced toggle for screen readers (765) * feat: add left padding to theme selector in prompts side nav panel so that focus outline doesnt clip * feat: darken orange bg for warning toasts to hit 3:1 contrast minimum with white text (725) * fix: return focus to triggering element on modal close for image preview in attach files panel (717) * fix: hide SVG for AddMultiConvo button from screen readers (708) * feat: add persistent label to Filter Memories... input in memory side panel
156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
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 });
|
|
}
|
|
};
|
|
|
|
// Automatically set isTyping to false when loading is done and debouncedQuery matches query
|
|
// (prevents stuck loading state if input is still focused)
|
|
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-2 border-transparent px-3 py-2 text-text-primary transition-all duration-200 focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover',
|
|
isSmallScreen === true ? 'mb-2 h-14 rounded-xl' : '',
|
|
)}
|
|
>
|
|
<Search
|
|
aria-hidden="true"
|
|
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;
|