LibreChat/client/src/components/Nav/SearchBar.tsx
Dustin Healy 86951cc56d
🛂 fix: Address Accessibility Issues - Axe Rating: Serious (#10607)
* 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
2025-12-04 14:34:43 -05:00

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;